iT邦幫忙

2022 iThome 鐵人賽

DAY 28
0

我們今天來看
什麼情況下結帳的話面會出現不一樣的元件

當你選購的方案為「訂閱方案」且價格為「0 元代幣」
這時候你點選「立即訂閱」時他會跳出下面這個 Modal

這邊的邏輯稍微複雜
因為他和結帳頁的邏輯是一樣的
簡單來說,他會先從你的 localstorage 取得你的「shipping」、「invoice」和「payment」
再來會根據你點擊購買的課程,去取得課程的相關資訊和 GA 所要使用的資料
再來會初始化你的付款資訊
最後會打 API 給後端確認取得訂單資訊
沒問題後即可以填寫資訊,完成訂單
如果有必填的沒填,會滾動至沒填的欄位,使用的是 useRef 的方式
明天我們來看結帳頁

今天的程式碼如下:

const CheckoutProductModal: React.VFC<CheckoutProductModalProps> = ({
  defaultProductId,
  warningText,
  startedAt,
  shippingMethods,
  productQuantity,
  isFieldsValidate,
  renderInvoice,
  renderTrigger,
  renderProductSelector,
  renderTerms,
  setIsModalDisable,
  setIsOrderCheckLoading,
}) => {
  const { formatMessage } = useIntl()
  const history = useHistory()
  const { isOpen, onOpen, onClose } = useDisclosure()
  const checkoutOpened = useRef(false)
  const [checkoutProductId] = useQueryParam('checkoutProductId', StringParam)
  const { enabledModules, settings, id: appId, currencyId: appCurrencyId } = useApp()
  const { currentMemberId, isAuthenticating, authToken } = useAuth()
  const { member: currentMember } = useMember(currentMemberId || '')
  const { memberCreditCards } = useMemberCreditCards(currentMemberId || '')
  const [quantity, setQuantity] = useState(1)

  useEffect(() => {
    if (!checkoutOpened.current && checkoutProductId === defaultProductId) {
      checkoutOpened.current = true
      onOpen()
    }
  }, [checkoutProductId])

  useEffect(() => {
    if (productQuantity !== undefined) {
      setQuantity(productQuantity)
    }
  }, [productQuantity])

  const sessionStorageKey = `lodestar.sharing_code.${defaultProductId}`
  const [sharingCode = window.sessionStorage.getItem(sessionStorageKey)] = useQueryParam('sharing', StringParam)
  sharingCode && window.sessionStorage.setItem(sessionStorageKey, sharingCode)

  const cachedCartInfo = useMemo<{
    shipping: ShippingProps | null
    invoice: InvoiceProps | null
    payment: PaymentProps | null
    contactInfo: ContactInfo | null
  }>(() => {
    const defaultCartInfo = {
      shipping: null,
      invoice: {
        name: currentMember?.name || '',
        phone: currentMember?.phone || '',
        email: currentMember?.email || '',
      },
      payment: null,
      contactInfo: {
        name: currentMember?.name || '',
        phone: currentMember?.phone || '',
        email: currentMember?.email || '',
      },
    }
    try {
      const cachedShipping = localStorage.getItem('kolable.cart.shipping')
      const cachedInvoice = localStorage.getItem('kolable.cart.invoice')
      const cachedPayment = localStorage.getItem('kolable.cart.payment.perpetual')
      cachedCartInfo.shipping = cachedShipping && JSON.parse(cachedShipping)
      cachedCartInfo.invoice = cachedInvoice && JSON.parse(cachedInvoice).value
      cachedCartInfo.payment = cachedPayment && JSON.parse(cachedPayment)
    } catch {}
    return defaultCartInfo
  }, [currentMember?.name, currentMember?.email, currentMember?.phone])

  // checkout
  const [productId, setProductId] = useState(defaultProductId)
  const { target: productTarget } = useSimpleProduct({ id: productId, startedAt })
  const { type, target } = getResourceByProductId(productId)
  const { resourceCollection } = useResourceCollection([`${appId}:${type}:${target}`])

  // tracking
  const tracking = useTracking()

  // cart information
  const memberCartInfo: {
    shipping?: ShippingProps | null
    invoice?: InvoiceProps | null
    payment?: PaymentProps | null
  } = {
    shipping: currentMember?.shipping,
    invoice: currentMember?.invoice,
    payment: currentMember?.payment,
  }

  const [shipping, setShipping] = useState<ShippingProps>({
    name: '',
    phone: '',
    address: '',
    shippingMethod: 'home-delivery',
    specification: '',
    storeId: '',
    storeName: '',
    ...memberCartInfo.shipping,
    ...cachedCartInfo.shipping,
  })
  const [invoice, setInvoice] = useState<InvoiceProps>({
    name: '',
    phone: '',
    email: currentMember?.email || '',
    ...memberCartInfo.invoice,
    ...cachedCartInfo.invoice,
  })

  const [payment, setPayment] = useState<PaymentProps | null | undefined>()
  const [isApproved, setIsApproved] = useState(settings['checkout.approvement'] !== 'true')
  useEffect(() => {
    setIsApproved(settings['checkout.approvement'] !== 'true')
  }, [settings])

  useEffect(() => {
    if (currentMember) {
      setInvoice(prev => ({ ...prev, ...cachedCartInfo.invoice }))
    }
  }, [cachedCartInfo.invoice])

  const initialPayment = useMemo(
    () =>
      (productTarget?.isSubscription
        ? {
            gateway: settings['payment.subscription.default_gateway'] || 'tappay',
            method: 'credit',
          }
        : {
            gateway: settings['payment.perpetual.default_gateway'] || 'spgateway',
            method: settings['payment.perpetual.default_gateway_method'] || 'credit',
            ...memberCartInfo.payment,
            ...cachedCartInfo.payment,
          }) as PaymentProps,
    [productTarget?.isSubscription, settings, memberCartInfo.payment, cachedCartInfo.payment],
  )

  useEffect(() => {
    if (typeof productTarget?.isSubscription === 'boolean') {
      setPayment(initialPayment)
    }
  }, [productTarget?.isSubscription, initialPayment])

  const shippingRef = useRef<HTMLDivElement | null>(null)
  const invoiceRef = useRef<HTMLDivElement | null>(null)
  const referrerRef = useRef<HTMLDivElement | null>(null)
  const groupBuyingRef = useRef<HTMLDivElement | null>(null)
  const paymentMethodRef = useRef<HTMLDivElement | null>(null)
  const contactInfoRef = useRef<HTMLDivElement | null>(null)
  const [discountId, setDiscountId] = useState('')
  useEffect(() => {
    if (
      productTarget?.currencyId === 'LSC' &&
      defaultProductId !== undefined &&
      defaultProductId.includes('MerchandiseSpec_')
    ) {
      setDiscountId('Coin')
    }
  }, [productTarget, defaultProductId])

  const [groupBuying, setGroupBuying] = useState<{
    memberIds: string[]
    withError: boolean
  }>({ memberIds: [], withError: false })

  const { totalPrice, placeOrder, check, orderChecking, orderPlacing } = useCheck({
    productIds: [productId],
    discountId,
    shipping: productTarget?.isPhysical
      ? shipping
      : productId.startsWith('MerchandiseSpec_')
      ? { address: currentMember?.email }
      : null,
    options: {
      [productId]: {
        startedAt,
        from: window.location.pathname,
        sharingCode,
        groupBuyingPartnerIds: groupBuying.memberIds,
        quantity: quantity,
      },
    },
  })
  const { TPDirect } = useTappay()
  const toast = useToast()
  const [isValidating, setIsValidating] = useState(false)
  const [referrerEmail, setReferrerEmail] = useState('')
  const [tpCreditCard, setTpCreditCard] = useState<TPCreditCard | null>(null)
  const [errorContactFields, setErrorContactFields] = useState<string[]>([])
  const { memberId: referrerId, validateStatus: referrerStatus } = useMemberValidation(referrerEmail)
  const updateMemberMetadata = useUpdateMemberMetadata()
  const isCreditCardReady = Boolean(memberCreditCards.length > 0 || tpCreditCard?.canGetPrime)
  const [isCoinMerchandise, setIsCoinMerchandise] = useState(false)
  const [isCoinsEnough, setIsCoinsEnough] = useState(true)
  const { remainingCoins } = useMemberCoinsRemaining(currentMemberId || '')
  useEffect(() => {
    if (check.orderProducts.length === 0) {
      setIsOrderCheckLoading?.(true)
      setIsModalDisable?.(true)
    } else if (
      check.orderProducts.length === 1 &&
      check.orderProducts[0].options?.currencyId === 'LSC' &&
      check.orderProducts[0].productId.includes('MerchandiseSpec_')
    ) {
      setIsOrderCheckLoading?.(false)
      setIsCoinMerchandise(true)
      if (
        check.orderProducts[0].options?.currencyPrice !== undefined &&
        remainingCoins !== undefined &&
        productQuantity !== undefined &&
        check.orderProducts[0].options.currencyPrice * productQuantity > remainingCoins
      ) {
        setIsCoinsEnough(false)
        setIsModalDisable?.(true)
      } else {
        setIsCoinsEnough(true)
        setIsModalDisable?.(false)
      }
    }
  }, [check, productQuantity, remainingCoins, setIsModalDisable, setIsOrderCheckLoading])

  if (isAuthenticating) {
    return renderTrigger?.({ isLoading: true })
  }

  if (currentMember === null) {
    return renderTrigger?.({ isLoginAlert: true })
  }

  if (productTarget === null || payment === undefined) {
    return renderTrigger?.({ isLoading: isAuthenticating, disable: true })
  }

  const handleSubmit = async () => {
    !isValidating && setIsValidating(true)
    let isValidShipping = false
    let isValidInvoice = false
    if (isFieldsValidate) {
      ;({ isValidInvoice, isValidShipping } = isFieldsValidate({ invoice, shipping }))
    } else {
      isValidShipping = !productTarget.isPhysical || validateShipping(shipping)
      isValidInvoice = Number(settings['feature.invoice.disable'])
        ? true
        : Number(settings['feature.invoice_member_info_input.disable'])
        ? validateInvoice(invoice).filter(v => !['name', 'phone', 'email'].includes(v)).length === 0
        : validateInvoice(invoice).length === 0
    }

    if (totalPrice > 0 && payment === null) {
      paymentMethodRef.current?.scrollIntoView({ behavior: 'smooth' })
      return
    }
    if (!isValidShipping) {
      shippingRef.current?.scrollIntoView({ behavior: 'smooth' })
      return
    } else if ((totalPrice > 0 || productTarget.discountDownPrice) && !isValidInvoice) {
      invoiceRef.current?.scrollIntoView({ behavior: 'smooth' })
      return
    }
    if (referrerStatus === 'error') {
      referrerRef.current?.scrollIntoView({ behavior: 'smooth' })
    }
    if (referrerEmail && referrerStatus !== 'success') {
      if (referrerStatus === 'error') {
        referrerRef.current?.scrollIntoView({ behavior: 'smooth' })
      }
      return
    }
    if (groupBuying.withError) {
      groupBuyingRef.current?.scrollIntoView({ behavior: 'smooth' })
      return
    }

    if (totalPrice <= 0 && settings['feature.contact_info.enabled'] === '1') {
      const errorFields = validateContactInfo(invoice)
      if (errorFields.length !== 0) {
        setErrorContactFields(errorFields)
        contactInfoRef.current?.scrollIntoView({ behavior: 'smooth' })
        return
      }
    }

    if (!isCoinsEnough) {
      toast({
        title: formatMessage(checkoutMessages.message.notEnoughCoins),
        status: 'error',
        duration: 3000,
        position: 'top',
      })
      return
    }

    if (settings['tracking.fb_pixel_id']) {
      ReactPixel.track('AddToCart', {
        content_name: productTarget.title || productId,
        value: totalPrice,
        currency: 'TWD',
      })
    }
    if (settings['tracking.ga_id']) {
      ReactGA.plugin.execute('ec', 'addProduct', {
        id: productId,
        name: productTarget.title || productId,
        category: productId.split('_')[0] || 'Unknown',
        price: `${totalPrice}`,
        quantity: '1',
        currency: 'TWD',
      })
      ReactGA.plugin.execute('ec', 'setAction', 'add')
      ReactGA.ga('send', 'event', 'UX', 'click', 'add to cart')
    }

    // free subscription should bind card first
    if (productTarget.isSubscription && totalPrice <= 0 && memberCreditCards.length === 0) {
      await new Promise((resolve, reject) => {
        const clientBackUrl = new URL(window.location.href)
        clientBackUrl.searchParams.append('checkoutProductId', productId)

        TPDirect.card.getPrime(({ status, card: { prime } }: { status: number; card: { prime: string } }) => {
          axios({
            method: 'POST',
            url: `${process.env.REACT_APP_API_BASE_ROOT}/payment/credit-cards`,
            withCredentials: true,
            data: {
              prime,
              cardHolder: {
                name: currentMember.name,
                email: currentMember.email,
                phoneNumber: currentMember.phone || '0987654321',
              },
              clientBackUrl,
            },
            headers: { authorization: `Bearer ${authToken}` },
          })
            .then(({ data: { code, result } }) => {
              if (code === 'SUCCESS') {
                resolve(result.memberCreditCardId)
              } else if (code === 'REDIRECT') {
                window.location.assign(result)
              }
              reject(code)
            })
            .catch(reject)
        })
      })
    }

    placeOrder(
      productTarget.isSubscription ? 'subscription' : 'perpetual',
      {
        ...invoice,
        referrerEmail: referrerEmail || undefined,
      },
      payment,
    )
      .then(taskId =>
        // sync cart info
        updateMemberMetadata({
          variables: {
            memberId: currentMember.id,
            metadata: {
              invoice,
              shipping,
              payment,
            },
            memberPhones: invoice.phone ? [{ member_id: currentMember.id, phone: invoice.phone }] : [],
          },
        }).then(() => taskId),
      )
      .then(taskId => history.push(`/tasks/order/${taskId}`))
      .catch(() => {})
  }

  return (
    <>
      {renderTrigger({
        onOpen,
        onProductChange: productId => setProductId(productId),
        isLoading: isAuthenticating,
        isSubscription: productTarget.isSubscription,
        disable:
          (productTarget.endedAt ? new Date(productTarget.endedAt) < new Date(now()) : false) ||
          (productTarget.expiredAt ? new Date(productTarget.expiredAt) < new Date(now()) : false),
      })}
      <CommonModal
        title={<StyledTitle className="mb-4">{formatMessage(checkoutMessages.title.cart)}</StyledTitle>}
        isOpen={isOpen}
        isFullWidth
        onClose={() => {
          onClose()
          const resource = resourceCollection.filter(notEmpty).length > 0 && resourceCollection[0]
          resource && tracking.removeFromCart(resource)
        }}
      >
        <div className="mb-4">
          <ProductItem
            id={productId}
            startedAt={startedAt}
            variant={
              settings['custom.project.plan_price_style'] === 'hidden' && productId.startsWith('ProjectPlan_')
                ? undefined
                : 'checkout'
            }
            quantity={quantity}
            onChange={value => typeof value === 'number' && setQuantity(value)}
          />
        </div>

        {settings['feature.contact_info.enabled'] === '1' && totalPrice === 0 && (
          <Box ref={contactInfoRef} mb="3">
            <ContactInfoInput value={invoice} onChange={v => setInvoice(v)} errorContactFields={errorContactFields} />
          </Box>
        )}

        {renderProductSelector && (
          <div className="mb-5">
            {renderProductSelector({ productId, onProductChange: productId => setProductId(productId) })}
          </div>
        )}

        {!!warningText && <StyledWarningText>{warningText}</StyledWarningText>}

        {productTarget.isPhysical && (
          <div ref={shippingRef}>
            <ShippingInput
              value={shipping}
              onChange={value => setShipping(value)}
              shippingMethods={shippingMethods}
              isValidating={isValidating}
            />
          </div>
        )}

        {enabledModules.group_buying && !!productTarget.groupBuyingPeople && productTarget.groupBuyingPeople > 1 && (
          <div ref={groupBuyingRef}>
            <StyledBlockTitle className="mb-3">{formatMessage(checkoutMessages.label.groupBuying)}</StyledBlockTitle>
            <OrderedList className="mb-4">
              <StyledListItem>{formatMessage(checkoutMessages.text.groupBuyingDescription1)}</StyledListItem>
              <StyledListItem>{formatMessage(checkoutMessages.text.groupBuyingDescription2)}</StyledListItem>
              <StyledListItem>
                {formatMessage(checkoutMessages.text.groupBuyingDescription3, { modal: <GroupBuyingRuleModal /> })}
              </StyledListItem>
            </OrderedList>
            <CheckoutGroupBuyingForm
              title={productTarget.title || ''}
              partnerCount={productTarget.groupBuyingPeople - 1}
              onChange={value => setGroupBuying(value)}
            />
          </div>
        )}

        {totalPrice > 0 && productTarget.isSubscription === false && (
          <div className="mb-5" ref={paymentMethodRef}>
            <PaymentSelector value={payment} onChange={v => setPayment(v)} isValidating={isValidating} />
          </div>
        )}

        {totalPrice <= 0 && productTarget.isSubscription && (
          <>
            {memberCreditCards[0]?.cardInfo?.['last_four'] ? (
              <Box borderWidth="1px" borderRadius="lg" w="100%" p={4}>
                <span>
                  {formatMessage(checkoutMessages.label.creditLastFour)}:{memberCreditCards[0].cardInfo['last_four']}
                </span>
              </Box>
            ) : (
              <TapPayForm onUpdate={setTpCreditCard} />
            )}
          </>
        )}
        {((totalPrice > 0 && productTarget?.currencyId !== 'LSC' && productTarget.productType !== 'MerchandiseSpec') ||
          productTarget.discountDownPrice) && (
          <>
            <div ref={invoiceRef} className="mb-5">
              {renderInvoice?.({ invoice, setInvoice, isValidating }) ||
                (settings['feature.invoice.disable'] !== '1' && (
                  <InvoiceInput
                    value={invoice}
                    onChange={value => setInvoice(value)}
                    isValidating={isValidating}
                    shouldSameToShippingCheckboxDisplay={productTarget.isPhysical}
                  />
                ))}
            </div>
            <div className="mb-3">
              <DiscountSelectionCard check={check} value={discountId} onChange={setDiscountId} />
            </div>
          </>
        )}

        {enabledModules.referrer && productTarget.currencyId !== undefined && productTarget.currencyId !== 'LSC' && (
          <div className="row mb-3" ref={referrerRef}>
            <div className="col-12">
              <StyledTitle className="mb-2">{formatMessage(commonMessages.label.referrer)}</StyledTitle>
            </div>
            <div className="col-12 col-lg-6">
              <CheckoutProductReferrerInput
                referrerId={referrerId}
                referrerStatus={referrerStatus}
                onEmailSet={email => setReferrerEmail(email)}
              />
            </div>
          </div>
        )}
        {settings['checkout.approvement'] === 'true' && (
          <div className="my-4">
            <StyledCheckbox
              className="mr-2"
              size="lg"
              colorScheme="primary"
              isChecked={isApproved}
              onChange={() => setIsApproved(prev => !prev)}
            />
            <StyledLabel>{formatMessage(checkoutMessages.label.approved)}</StyledLabel>
            <StyledApprovementBox
              className="mt-2"
              dangerouslySetInnerHTML={{ __html: settings['checkout.approvement_content'] }}
            />
          </div>
        )}
        <Divider className="mb-3" />
        {renderTerms && (
          <StyledCheckoutBlock className="mb-5">
            <div className="mb-2">{renderTerms()}</div>
          </StyledCheckoutBlock>
        )}
        {settings['custom.project.plan_price_style'] === 'hidden' &&
        productId.startsWith('ProjectPlan_') ? null : orderChecking ? (
          <SkeletonText noOfLines={4} spacing="5" />
        ) : (
          <>
            <StyledCheckoutBlock className="mb-5">
              {check.orderProducts.map(orderProduct => (
                <CheckoutProductItem
                  key={orderProduct.name}
                  name={orderProduct.name}
                  price={
                    orderProduct.productId.includes('MerchandiseSpec_') && orderProduct.options?.currencyId === 'LSC'
                      ? orderProduct.options.currencyPrice || orderProduct.price
                      : orderProduct.price
                  }
                  quantity={quantity}
                  saleAmount={Number((orderProduct.options?.amount || 1) / quantity)}
                  defaultProductId={defaultProductId}
                  currencyId={orderProduct.options?.currencyId || appCurrencyId}
                />
              ))}

              {check.orderDiscounts.map((orderDiscount, idx) => (
                <CheckoutProductItem
                  key={orderDiscount.name}
                  name={orderDiscount.name}
                  price={
                    check.orderProducts[0]?.productId.includes('MerchandiseSpec_') &&
                    check.orderProducts[0].options?.currencyId === 'LSC'
                      ? -orderDiscount.options?.coins
                      : -orderDiscount.price
                  }
                  currencyId={productTarget.currencyId}
                />
              ))}
              {check.shippingOption && (
                <CheckoutProductItem
                  name={formatMessage(
                    checkoutMessages.shipping[camelCase(check.shippingOption.id) as ShippingOptionIdType],
                  )}
                  price={check.shippingOption.fee}
                />
              )}
            </StyledCheckoutBlock>
            <StyledCheckoutPrice className="mb-3">
              {!isCoinMerchandise || isCoinsEnough ? (
                <PriceLabel listPrice={totalPrice} />
              ) : (
                `${settings['coin.unit'] || check.orderProducts[0].options?.currencyId} ${formatMessage(
                  checkoutMessages.message.notEnough,
                )}`
              )}
            </StyledCheckoutPrice>
          </>
        )}

        <StyledSubmitBlock className="text-right">
          <Button
            variant="outline"
            onClick={() => {
              onClose()
              const resource = resourceCollection.filter(notEmpty).length > 0 && resourceCollection[0]
              resource && tracking.removeFromCart(resource)
            }}
            className="mr-3"
          >
            {formatMessage(commonMessages.ui.cancel)}
          </Button>
          <Button
            colorScheme="primary"
            isLoading={orderPlacing}
            onClick={handleSubmit}
            disabled={
              (totalPrice === 0 && productTarget.isSubscription && !isCreditCardReady) ||
              isApproved === false ||
              !isCoinsEnough
            }
          >
            {productTarget.isSubscription
              ? formatMessage(commonMessages.button.subscribeNow)
              : formatMessage(checkoutMessages.button.cartSubmit)}
          </Button>
        </StyledSubmitBlock>
      </CommonModal>
    </>
  )
}

上一篇
checkout (2)
下一篇
chechout (4)
系列文
從 Open Source 專案學習 React 開發 - 以 lodestar-app 為例30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言